🎖️GitЯра🎖️
Commit 10c5b5db2e5b23c52a3a5689205bfc42285b7b0a
Parents : 82135df
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-05-11T19:34:41-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-05-11T19:34:41-05:00
feat(api): add hasAnyEntries method to local data sources and improve… (#5406)
Changes
6 files changed, 92 insertions(+), 199 deletions(-)
Diff
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
index eaca1ea7be..852ac08989 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
@@ -45,4 +45,6 @@ class DeviceHardwareLocalDataSource(
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity? =
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
+
+ suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { deviceHardwareDao.count() > 0 }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt
index 77d90a421d..31f1845b70 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt
@@ -55,4 +55,6 @@ class FirmwareReleaseLocalDataSource(
return@withContext latestRelease
}
}
+
+ suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { firmwareReleaseDao.count() > 0 }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 1ff5657040..c7c805ee13 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -33,7 +33,6 @@ import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
-// Annotating with Singleton to ensure a single instance manages the cache
@Single
class DeviceHardwareRepositoryImpl(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
@@ -46,18 +45,11 @@ class DeviceHardwareRepositoryImpl(
/**
* Retrieves device hardware information by its model ID and optional target string.
*
- * This function implements a cache-aside pattern with a fallback mechanism:
- * 1. Check for a valid, non-expired local cache entry.
- * 2. If not found or expired, fetch fresh data from the remote API.
- * 3. If the remote fetch fails, attempt to use stale data from the cache.
- * 4. If the cache is empty, fall back to loading data from a bundled JSON asset.
- *
- * @param hwModel The hardware model identifier.
- * @param target Optional PlatformIO target environment name to disambiguate multiple variants.
- * @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely.
- * @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
+ * Pipeline:
+ * 1. If the local DB is empty, seed it from the bundled JSON asset (instant baseline).
+ * 2. If the cached entry is stale or missing, refresh from the remote API.
+ * 3. Return the best available data from the DB.
*/
- @Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
override suspend fun getDeviceHardwareByModel(
hwModel: Int,
target: String?,
@@ -71,146 +63,65 @@ class DeviceHardwareRepositoryImpl(
val quirks = loadQuirks()
if (forceRefresh) {
- Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
+ Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing cache" }
localDataSource.deleteAllDeviceHardware()
- } else {
- // 1. Attempt to retrieve from cache first
- var cachedEntities = localDataSource.getByHwModel(hwModel)
-
- // Fallback to target-only lookup if hwModel-based lookup yielded nothing
- if (cachedEntities.isEmpty() && target != null) {
- Logger.d {
- "DeviceHardwareRepository: no cache for hwModel=$hwModel, trying target lookup for $target"
- }
- val byTarget = localDataSource.getByTarget(target)
- if (byTarget != null) {
- cachedEntities = listOf(byTarget)
- }
- }
-
- if (cachedEntities.isNotEmpty() && cachedEntities.all { !it.isStale() }) {
- Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
- val matched = disambiguate(cachedEntities, target)
- return@withContext Result.success(
- applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
- )
- }
- Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
}
- // 2. Fetch from remote API
- safeCatching {
- Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
- val remoteHardware = remoteDataSource.getAllDeviceHardware()
- Logger.d {
- "DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
- }
-
- localDataSource.insertAllDeviceHardware(remoteHardware)
- var fromDb = localDataSource.getByHwModel(hwModel)
-
- // Fallback to target lookup after remote fetch
- if (fromDb.isEmpty() && target != null) {
- val byTarget = localDataSource.getByTarget(target)
- if (byTarget != null) fromDb = listOf(byTarget)
- }
-
- Logger.d {
- "DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel returned" +
- " ${fromDb.size} entries"
- }
- disambiguate(fromDb, target)?.asExternalModel()
+ // 1. Seed from bundled JSON if the DB is completely empty (first launch or post-wipe).
+ if (!localDataSource.hasAnyEntries()) {
+ seedCacheFromBundledJson()
}
- .onSuccess {
- // Successfully fetched and found the model
- return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks, target))
- }
- .onFailure { e ->
- Logger.w(e) {
- "DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=$hwModel"
- }
-
- // 3. Attempt to use stale cache as a fallback, but only if it looks complete.
- var staleEntities = localDataSource.getByHwModel(hwModel)
- if (staleEntities.isEmpty() && target != null) {
- val byTarget = localDataSource.getByTarget(target)
- if (byTarget != null) staleEntities = listOf(byTarget)
- }
- if (staleEntities.isNotEmpty() && staleEntities.all { !it.isIncomplete() }) {
- Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
- val matched = disambiguate(staleEntities, target)
- return@withContext Result.success(
- applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
- )
- }
+ // 2. Check cache; refresh from network if stale, empty, or forced.
+ var entities = lookupEntities(hwModel, target)
+ if (forceRefresh || entities.isEmpty() || entities.any { it.isStale() }) {
+ refreshFromNetwork()
+ entities = lookupEntities(hwModel, target)
+ }
- // 4. Fallback to bundled JSON if cache is empty or incomplete
- Logger.d {
- "DeviceHardwareRepository: cache ${if (staleEntities.isEmpty()) "empty" else "incomplete"} " +
- "for hwModel=$hwModel, falling back to bundled JSON asset"
- }
- return@withContext loadFromBundledJson(hwModel, target, quirks)
- }
+ // 3. Resolve and return the best available data.
+ val matched = disambiguate(entities, target)
+ Result.success(applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target))
}
- private suspend fun loadFromBundledJson(
- hwModel: Int,
- target: String?,
- quirks: List<BootloaderOtaQuirk>,
- ): Result<DeviceHardware?> = safeCatching {
- Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
- val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
- Logger.d {
- "DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
+ /** Looks up entities by hwModel, falling back to a target-only lookup when needed. */
+ private suspend fun lookupEntities(hwModel: Int, target: String?): List<DeviceHardwareEntity> =
+ localDataSource.getByHwModel(hwModel).ifEmpty {
+ target?.let { listOfNotNull(localDataSource.getByTarget(it)) } ?: emptyList()
}
- localDataSource.insertAllDeviceHardware(jsonHardware)
- var baseList = localDataSource.getByHwModel(hwModel)
-
- // Fallback to target lookup after JSON load
- if (baseList.isEmpty() && target != null) {
- val byTarget = localDataSource.getByTarget(target)
- if (byTarget != null) baseList = listOf(byTarget)
+ private suspend fun seedCacheFromBundledJson() {
+ safeCatching {
+ Logger.d { "DeviceHardwareRepository: seeding cache from bundled JSON" }
+ val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
+ localDataSource.insertAllDeviceHardware(jsonHardware)
}
+ .onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: failed to seed cache from bundled JSON" } }
+ }
- Logger.d {
- "DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel returned ${baseList.size} entries"
+ private suspend fun refreshFromNetwork() {
+ safeCatching {
+ Logger.d { "DeviceHardwareRepository: fetching from remote API" }
+ val remoteHardware = remoteDataSource.getAllDeviceHardware()
+ Logger.d { "DeviceHardwareRepository: remote returned ${remoteHardware.size} entries" }
+ localDataSource.insertAllDeviceHardware(remoteHardware)
}
-
- val matched = disambiguate(baseList, target)
- applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target)
+ .onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: network refresh failed" } }
}
- .also { result ->
- result.exceptionOrNull()?.let { e ->
- Logger.e(e) {
- "DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
- }
- }
- }
-
- private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? = when {
- entities.isEmpty() -> null
- target == null -> entities.first()
+ private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? =
+ when (target) {
+ null -> entities.firstOrNull()
- else -> {
- entities.find { it.platformioTarget == target }
- ?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
- ?: entities.first()
+ else ->
+ entities.find { it.platformioTarget == target }
+ ?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
+ ?: entities.firstOrNull()
}
- }
- /** Returns true if the cached entity is missing important fields and should be refreshed. */
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
displayName.isBlank() || platformioTarget.isBlank() || images.isNullOrEmpty()
- /**
- * Extension function to check if the cached entity is stale.
- *
- * We treat entries with missing critical fields (e.g., no images or target) as stale so that they can be
- * automatically healed from newer JSON snapshots even if their timestamp is recent.
- */
private fun DeviceHardwareEntity.isStale(): Boolean =
isIncomplete() || (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
@@ -225,32 +136,25 @@ class DeviceHardwareRepositoryImpl(
base: DeviceHardware?,
quirks: List<BootloaderOtaQuirk>,
reportedTarget: String? = null,
- ): DeviceHardware? {
- if (base == null) return null
-
+ ): DeviceHardware? = base?.let { hw ->
val matchedQuirk = quirks.firstOrNull { it.hwModel == hwModel }
- val result =
+ val withQuirk =
if (matchedQuirk != null) {
Logger.d {
"DeviceHardwareRepository: applying quirk: " +
"requiresBootloaderUpgradeForOta=${matchedQuirk.requiresBootloaderUpgradeForOta}, " +
"infoUrl=${matchedQuirk.infoUrl}"
}
- base.copy(
+ hw.copy(
requiresBootloaderUpgradeForOta = matchedQuirk.requiresBootloaderUpgradeForOta,
bootloaderInfoUrl = matchedQuirk.infoUrl,
)
} else {
- base
+ hw
}
// If the device reported a specific build environment via pio_env, trust it for firmware retrieval
- return if (reportedTarget != null) {
- Logger.d { "DeviceHardwareRepository: using reported target $reportedTarget for hardware info" }
- result.copy(platformioTarget = reportedTarget)
- } else {
- result
- }
+ reportedTarget?.let { withQuirk.copy(platformioTarget = it) } ?: withQuirk
}
companion object {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
index cfb0452271..8739d0844c 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
@@ -40,11 +40,14 @@ open class FirmwareReleaseRepositoryImpl(
) : FirmwareReleaseRepository {
/**
- * A flow that provides the latest STABLE firmware release. It follows a "cache-then-network" strategy:
- * 1. Immediately emits the cached version (if any).
- * 2. If the cached version is stale, triggers a network fetch in the background.
- * 3. Emits the updated version upon successful fetch. Collectors should use `.distinctUntilChanged()` to avoid
- * redundant UI updates.
+ * A flow that provides the latest STABLE firmware release.
+ *
+ * Pipeline:
+ * 1. If the local DB is empty, seed it from the bundled JSON asset (instant baseline).
+ * 2. Immediately emit the cached version.
+ * 3. If the cached version is stale, refresh from the remote API and re-emit.
+ *
+ * Collectors should use `.distinctUntilChanged()` to avoid redundant UI updates.
*/
override val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
@@ -55,76 +58,52 @@ open class FirmwareReleaseRepositoryImpl(
*/
override val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
- private fun getLatestFirmware(
- releaseType: FirmwareReleaseType,
- forceRefresh: Boolean = false,
- ): Flow<FirmwareRelease?> = flow {
- if (forceRefresh) {
- invalidateCache()
+ private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow<FirmwareRelease?> = flow {
+ // 1. Seed from bundled JSON if the DB is completely empty (first launch or post-wipe).
+ if (!localDataSource.hasAnyEntries()) {
+ seedCacheFromBundledJson()
}
- // 1. Emit cached data first, regardless of staleness.
- // This gives the UI something to show immediately.
+ // 2. Emit cached data immediately.
val cachedRelease = localDataSource.getLatestRelease(releaseType)
- if (cachedRelease != null) {
- Logger.d { "Emitting cached firmware for $releaseType (isStale=${cachedRelease.isStale()})" }
- emit(cachedRelease.asExternalModel())
- } else {
- emit(null)
- }
+ emit(cachedRelease?.asExternalModel())
- // 2. If the cache was fresh and we are not forcing a refresh, we're done.
- if (cachedRelease != null && !cachedRelease.isStale() && !forceRefresh) {
+ // 3. If fresh, we're done.
+ if (cachedRelease?.isStale() == false) {
return@flow
}
- // 3. Cache is stale, empty, or refresh is forced. Fetch new data.
- updateCacheFromSources()
-
- // 4. Emit the final, updated value from the cache.
- // The `distinctUntilChanged()` operator on the collector side will prevent
- // re-emitting the same data if the cache wasn't actually updated.
+ // 4. Cache is stale or empty — refresh from network and re-emit.
+ refreshFromNetwork()
val finalRelease = localDataSource.getLatestRelease(releaseType)
Logger.d { "Emitting final firmware for $releaseType from cache." }
emit(finalRelease?.asExternalModel())
}
- /**
- * Updates the local cache by fetching from the remote API, with a fallback to a bundled JSON asset if the remote
- * fetch fails.
- *
- * This method is efficient because it fetches and caches all release types (stable, alpha, etc.) in a single
- * operation.
- */
- private suspend fun updateCacheFromSources() {
- val remoteFetchSuccess =
- safeCatching {
- Logger.d { "Fetching fresh firmware releases from remote API." }
- val networkReleases = remoteDataSource.getFirmwareReleases()
-
- // The API fetches all release types, so we cache them all at once.
- localDataSource.insertFirmwareReleases(networkReleases.releases.stable, FirmwareReleaseType.STABLE)
- localDataSource.insertFirmwareReleases(networkReleases.releases.alpha, FirmwareReleaseType.ALPHA)
- }
- .isSuccess
-
- // If remote fetch failed, try the JSON fallback as a last resort.
- if (!remoteFetchSuccess) {
- Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
- safeCatching {
- val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
- localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
- localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
- }
- .onFailure { Logger.w { "Failed to cache from JSON: ${it.message}" } }
+ private suspend fun refreshFromNetwork() {
+ safeCatching {
+ Logger.d { "Fetching fresh firmware releases from remote API." }
+ val networkReleases = remoteDataSource.getFirmwareReleases()
+ localDataSource.insertFirmwareReleases(networkReleases.releases.stable, FirmwareReleaseType.STABLE)
+ localDataSource.insertFirmwareReleases(networkReleases.releases.alpha, FirmwareReleaseType.ALPHA)
}
+ .onFailure { e -> Logger.w(e) { "FirmwareReleaseRepository: network refresh failed" } }
}
override suspend fun invalidateCache() {
localDataSource.deleteAllFirmwareReleases()
}
- /** Extension function to check if the cached entity is stale. */
+ private suspend fun seedCacheFromBundledJson() {
+ safeCatching {
+ Logger.d { "FirmwareReleaseRepository: seeding cache from bundled JSON" }
+ val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
+ localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
+ localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
+ }
+ .onFailure { e -> Logger.w(e) { "FirmwareReleaseRepository: failed to seed cache from bundled JSON" } }
+ }
+
private fun FirmwareReleaseEntity.isStale(): Boolean = (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
companion object {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index a6ca6e65a9..01f61e3ee8 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -36,6 +36,9 @@ interface DeviceHardwareDao {
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel AND platformio_target = :target")
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity?
+ @Query("SELECT COUNT(*) FROM device_hardware")
+ suspend fun count(): Int
+
@Query("DELETE FROM device_hardware")
suspend fun deleteAll()
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
index e17a0af1ab..dfd5d39625 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
@@ -34,4 +34,7 @@ interface FirmwareReleaseDao {
@Query("SELECT * FROM firmware_release WHERE release_type = :releaseType")
suspend fun getReleasesByType(releaseType: FirmwareReleaseType): List<FirmwareReleaseEntity>
+
+ @Query("SELECT COUNT(*) FROM firmware_release")
+ suspend fun count(): Int
}
Served by rngit 1.5.4 - Generated in 0.11s